<?php

// This file is part of Minz.
// Copyright 2020-2025 Marien Fressinaud
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace Minz\Form;

use Minz\Validable;

/**
 * A trait to protect against CSRF attacks.
 *
 * You can handle CSRF validation with this trait. It is recommended to declare
 * it in a BaseForm.
 *
 *     use Minz\Form;
 *
 *     class BaseForm extends Form
 *     {
 *         use Form\Csrf;
 *     }
 *
 * This trait checks two things:
 *
 * - that the CSRF token set in the HTML form (with the `csrf_token` name) is
 *   valid;
 * - that the Origin of the request (or Referer if the Origin header is missing)
 *   matches the base url of the application.
 *
 * The token is generated using two variables:
 *
 * - the session id: it is generated by the form and stored in the session by
 *   default. You can change this behaviour by overriding the `csrfSessionId()`
 *   method, to return a session id of a "remember me" session for instance.
 * - the form name: it is generated using the form class. You can change this
 *   behaviour by overriding the `csrfTokenName()` method.
 *
 *     use App\CurrentUser;
 *     use Minz\Form;
 *
 *     class BaseForm extends Form
 *     {
 *         use Form\Csrf;
 *
 *         public function csrfSessionId(): string
 *         {
 *             $session_id = CurrentUser::sessionId();
 *
 *             if (!$session_id) {
 *                 $session_id = parent::csrfSessionId();
 *             }
 *
 *             return $session_id;
 *         }
 *
 *         // This will generally be done in child forms
 *         public function csrfTokenName(): string
 *         {
 *             return 'my_form';
 *         }
 *     }
 *
 * If the token and/or the origin are invalid, an error is added to the `@base`
 * errors of the form. The message is generated by the `csrfErrorMessage()`
 * method which you can override (e.g. to translate it).
 *
 *     use Minz\Form;
 *
 *     class BaseForm extends Form
 *     {
 *         use Form\Csrf;
 *
 *         public function csrfErrorMessage(): string
 *         {
 *             return _('CSRF token is invalid, try to resubmit the form.');
 *         }
 *     }
 *
 * Don't forget to include the CSRF token and error in the form view:
 *
 *     <?php if ($form->isInvalid('@base')): ?>
 *         <p role="alert">
 *             <?= $form->error('@base') ?>
 *         </p>
 *     <?php endif ?>
 *
 *     <input type="hidden" name="csrf_token" value="<?= $form->csrfToken() ?>" />
 *
 * @see \Minz\Form
 * @see \Minz\Form\CsrfToken
 *
 * @see https://en.wikipedia.org/wiki/Cross-site_request_forgery
 * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
 */
trait Csrf
{
    #[Field(bind: false)]
    public string $csrf_token = '';

    private ?string $csrf_origin = null;

    /**
     * Get the Origin header of the request. Use Referer if Origin is missing.
     */
    #[OnHandleRequest]
    public function rememberCsrfOrigin(\Minz\Request $request): void
    {
        $origin = $request->headers->getString('Origin');

        if ($origin === null) {
            $referer = $request->headers->getString('Referer');

            if ($referer === null) {
                return;
            }

            $parsed_referer = parse_url($referer);
            $referer_scheme = $parsed_referer['scheme'] ?? '';
            $referer_host = $parsed_referer['host'] ?? '';
            $referer_port = $parsed_referer['port'] ?? '';

            $origin = "{$referer_scheme}://{$referer_host}";
            if ($referer_port) {
                $origin .= ":{$referer_port}";
            }
        }

        $this->csrf_origin = $origin;
    }

    /**
     * Check that the CSRF token from the form is valid and that the request
     * has been initiated by the application by checking its origin.
     *
     * Add an error to the @base namespace otherwise.
     */
    #[Validable\Check]
    public function checkCsrf(): void
    {
        if ($this->csrf_origin === 'null') {
            \Minz\Log::error(
                'The browser sent a "null" Origin header with an origin protected ' .
                'form. That probably means that the request comes from a site that ' .
                'does not send its origin, or that you have the "no-referrer" ' .
                'Referrer-Policy header enabled.'
            );
        }

        $csrf = new CsrfToken($this->csrfSessionId());
        $base_url = \Minz\Url::baseUrl();

        if (
            !$csrf->validate($this->csrf_token, $this->csrfTokenName()) ||
            $base_url !== $this->csrf_origin
        ) {
            $this->addError('@base', 'csrf', $this->csrfErrorMessage());
        }
    }

    /**
     * Return a valid CSRF token for this form. This method must only be used
     * in the HTML form.
     *
     * @return non-empty-string
     */
    public function csrfToken(): string
    {
        $csrf = new CsrfToken($this->csrfSessionId());
        return $csrf->get($this->csrfTokenName());
    }

    /**
     * Return the CSRF session id that is used to generate the token.
     *
     * The id is stored in the $_SESSION with the `_csrf_session_id` key. The
     * id expires with the session.
     *
     * You can override this method to customize this behaviour, e.g. to
     * maintain the id over the real user session if you use a "remember me"
     * mechanism.
     *
     * @return non-empty-string
     */
    public function csrfSessionId(): string
    {
        if (!isset($_SESSION['_csrf_session_id'])) {
            $_SESSION['_csrf_session_id'] = \Minz\Random::hex(32);
        }

        $session_id = $_SESSION['_csrf_session_id'];

        if (!is_string($session_id) || $session_id === '') {
            throw new \LogicException('CSRF session id must be a non-empty string');
        }

        return $session_id;
    }

    /**
     * Return the CSRF token name (the Form class by default).
     *
     * You can customize the name by overriding the method in the child forms.
     *
     * @return non-empty-string
     */
    public function csrfTokenName(): string
    {
        return static::class;
    }

    /**
     * Return the error message to be displayed to the end user.
     *
     * Override the method to customize the message.
     *
     * @return non-empty-string
     */
    public function csrfErrorMessage(): string
    {
        return 'The security token is invalid. Please try to submit the form again.';
    }
}
